// (c) Copyright 2013-2014 Stephen Gold <sgold@sonic.net>
// Distributed under the terms of the GNU General Public License

/*
 This file is part of the Bats Game.

 The Bats Game is free software: you can redistribute it and/or modify
 it under the terms of the GNU General Public License as published by the
 Free Software Foundation, either version 3 of the License, or (at your
 option) any later version.

 The Bats Game is distributed in the hope that it will be useful, but
 WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
 or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License
 for more details.

 You should have received a copy of the GNU General Public License
 along with the Bats Game.  If not, see <http://www.gnu.org/licenses/>.
 */
package bats.map;

import bats.Assets;
import bats.BatsPrinter;
import bats.SpatialProperties;
import bats.scene.NodeControl;
import bats.scene.PickablesNode;
import bats.scene.SceneNode;
import com.jme3.collision.CollisionResult;
import com.jme3.collision.CollisionResults;
import com.jme3.math.Ray;
import com.jme3.math.Vector3f;
import com.jme3.scene.Node;
import com.jme3.scene.SceneGraphVisitor;
import com.jme3.scene.Spatial;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.PrintStream;
import java.io.UnsupportedEncodingException;
import java.util.logging.Level;
import java.util.logging.Logger;
import jme3utilities.Misc;
import jme3utilities.MySpatial;
import jme3utilities.MyString;
import jme3utilities.math.VectorXZ;
import jme3utilities.xml.DomInput;
import jme3utilities.xml.XmlLoader;
import jme3utilities.xml.XmlOutput;
import org.w3c.dom.Attr;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NamedNodeMap;
import org.w3c.dom.NodeList;

/**
 * A node control to manage the map (fixed geometries and static objects) in a
 * world.
 * <p>
 * Map blocks, a terrain mesh, and/or a ground plane may be attached to the
 * controlled node.
 *
 * @author Stephen Gold <sgold@sonic.net>
 */
public class MapNode
        extends NodeControl {
    // *************************************************************************
    // constants

    /**
     * coefficient of friction (&ge;0)
     */
    final public static float friction = 0.7f;
    /**
     * message logger for this class
     */
    final private static Logger logger =
            Logger.getLogger(MapNode.class.getName());
    /**
     * name for the node in the scene graph
     */
    final private static String nodeName = "map";
    /**
     * file name for a saved custom map
     */
    final private static String saveFileName = "Bats saved map.xml";
    /**
     * encoding used in XML files
     */
    final private static String xmlEncoding = "UTF-8";
    /**
     * tag for maps in XML files
     */
    final private static String xmlTag = "worldMap";
    /**
     * XML version used in XML files
     */
    final private static String xmlVersion = "1.0";
    // *************************************************************************
    // fields
    /**
     * the blocks in this map: set by constructor
     */
    final private Blocks blocks;
    /**
     * y-coordinate of the ground plane
     */
    private float groundY = 0f;
    /**
     * distance from the origin to the nearest map edge (in meters, &gt;0): set
     * by constructor
     */
    private float radius;
    /**
     * ground plane in this world map
     */
    private GroundControl ground = null;
    /**
     * terrain in this world map
     */
    private TerrainControl terrain = null;
    // *************************************************************************
    // constructors

    /**
     * Instantiate the enabled control for a specified scene. After
     * instantiation, the first method invoked should be initialize().
     *
     * @param scene which scene will contain this control (not null)
     * @param radius distance from origin to nearest edge (in meters, &gt;0)
     */
    public MapNode(SceneNode scene, float radius) {
        super(scene);

        blocks = new Blocks(scene);
        assert radius > 0f : radius;
        this.radius = radius;

        assert isEnabled();
    }
    // *************************************************************************
    // new methods exposed

    /**
     * Add a flat horizontal ground plane to the map.
     *
     * The ground plane is as deep in the world as anything can go. A map may
     * contain at most one ground plane.
     *
     * @param topY y-coordinate for the new plane
     */
    public void addGround(float topY) {
        if (ground != null) {
            logger.log(Level.WARNING, "Extra ground plane ignored.");
            return;
        }
        ground = new GroundControl(scene, topY);
        groundY = topY;
    }

    /**
     * Access the blocks in this map.
     *
     * @return the pre-existing instance (not null)
     */
    public Blocks getBlocks() {
        assert blocks != null;
        return blocks;
    }

    /**
     * Find the Y coordinate of the highest supporting surface for a specified
     * map location. BUG: Doesn't work during initialization!
     *
     * @param mapLocation where on the map (not null)
     * @return y-coordinate of the highest map surface at that location
     */
    public float getMapY(VectorXZ mapLocation) {
        float startY = MySpatial.getMaxY(spatial);
        Vector3f startLocation = mapLocation.toVector3f(startY);
        float fuzz = 1f; // meters
        Float dropDistance = measureDrop(startLocation, fuzz);
        float scaleFactor = scene.getScaleFactor();
        float result = startY - dropDistance / scaleFactor;
        return result;
    }

    /**
     * Get the horizontal extent of the map.
     *
     * @return distance from origin to nearest edge (in meters, &gt;0)
     */
    public float getRadius() {
        assert radius > 0f : radius;
        return radius;
    }

    /**
     * Test whether this map contains a specified type of block.
     *
     * @param blockType which type of block to search for (not null)
     * @return true if the map contains a block of that type, otherwise false
     */
    public boolean hasBlock(String blockType) {
        for (Spatial child : getChildren()) {
            if (SpatialProperties.isBlock(child)) {
                String prefix = SpatialProperties.getPrefixType(child);
                if (blockType.equals(prefix)) {
                    return true;
                }
            }
        }
        return false;
    }

    /**
     * Test whether this map has a ground plane.
     *
     * @return true if it has one, false if it doesn't
     */
    public boolean hasGround() {
        return ground != null;
    }

    /**
     * Test whether this map has terrain.
     *
     * @return true if it has terrain, false if it doesn't
     */
    public boolean hasTerrain() {
        return terrain != null;
    }

    /**
     * Initialize this control.
     */
    public void initialize() {
        assert spatial == null : spatial;

        Node node = new Node(nodeName);
        node.addControl(this);
        /*
         * Attach the map node to the scene graph as part of the world.
         */
        scene.getWorld().attach(spatial);

        assert spatial != null;
        assert !MySpatial.isOrphan(spatial);
    }

    /**
     * Make all blocks in this map pickable.
     */
    public void makeBlocksPickable() {
        PickablesNode pickables = scene.getWorld().getPickables();
        for (Spatial child : getChildren()) {
            if (SpatialProperties.isBlock(child)) {
                pickables.add(child);
            }
        }
    }

    /**
     * Search downward in the map geometry to find the nearest supporting
     * surface and measure the vertical drop to get there. BUG: Doesn't work
     * during initialization!
     *
     * @param startLocation starting location for the search (not null)
     * @param fuzz error tolerance on the starting location (in meters, &ge;0)
     * @return vertical distance (in meters downward) to the first supporting
     * surface, or null if none found
     */
    public Float measureDrop(Vector3f startLocation, float fuzz) {
        assert fuzz >= 0f : fuzz;
        logger.log(Level.INFO, "location = {0}, fuzz = {1}",
                new Object[]{startLocation, fuzz});
        /*
         * Create a downward-pointing geometric ray, with its vertex just
         * above the start location.
         */
        float scaleFactor = scene.getScaleFactor();
        Vector3f rayVertex = startLocation.add(0f, fuzz / scaleFactor, 0f);
        Vector3f downward = new Vector3f(0f, -1f, 0f);
        Ray ray = new Ray(rayVertex, downward);
        /*
         * Trace the ray to the nearest map geometry.
         */
        CollisionResults results = new CollisionResults();
        spatial.collideWith(ray, results);

        if (results.size() == 0) {
            return null;
        }
        CollisionResult nearest = results.getClosestCollision();
        float result = nearest.getDistance() * scaleFactor - fuzz;

        return Float.valueOf(result);
    }

    /**
     * Populate this map.
     *
     * @param mapName name of the map (or null for none)
     */
    public void populate(String mapName) {
        if (mapName != null) {
            if (!scene.isBuildMode()) {
                scene.goBuild();
            }
            load(mapName);
        }
    }

    /**
     * Process a hotkey or button action which has just begun.
     *
     * @param words action string split into an array of words (not null)
     * @return true if the action was handled, false if it wasn't handled
     */
    public boolean processAction(String[] words) {
        String verb = words[0];
        switch (verb) {
            case "load": {
                assert words.length > 1 : words;
                String what = words[1];
                if ("map".equals(what) && words.length == 3) {
                    String which = words[2];
                    load(which);
                    return true;
                }
                break;
            }

            case "lock":
                setLockedAllGateways(true);
                return true;

            case "print": {
                if (words.length == 2) {
                    String what = words[1];
                    if ("map".equals(what)) {
                        new BatsPrinter().printSubtree(spatial);
                        return true;
                    }
                }
                break;
            }

            case "save": {
                assert words.length > 1;
                String what = words[1];
                if ("map".equals(what)) {
                    saveUserFile();
                    return true;
                }
                break;
            }

            case "toggle": {
                assert words.length > 1;
                String what = words[1];
                switch (what) {
                    case "ground":
                        toggleGround();
                        return true;
                    case "terrain":
                        toggleTerrain();
                        return true;
                }
                break;
            }

            case "unlock":
                setLockedAllGateways(false);
                return true;
        }

        boolean wasHandled = blocks.processAction(words);
        if (wasHandled) {
            return true;
        }
        return false;
    }

    /**
     * Reload all map components. This cleans up any spent missiles, for
     * example.
     */
    public void reload() {
        for (Spatial child : getChildren()) {
            if (ground != null && child == ground.getSpatial()) {
                ground.reload();
            } else if (terrain != null && child == terrain.getSpatial()) {
                terrain.reload();
            } else if (SpatialProperties.isBlock(child)) {
                blocks.reload(child);
            } else {
                scene.deleteSpatial(child);
            }
        }
    }

    /**
     * Turn this map's ground plane on or off.
     *
     * @param newState true to turn on, false to turn off
     */
    public void setGround(boolean newState) {
        if (!hasGround() && newState) {
            addGround(groundY);

        } else if (hasGround() && !newState) {
            ground.delete();
            ground = null;
        }
    }

    /**
     * Turn this map's terrain on or off.
     *
     * @param newState true to turn on, false to turn off
     */
    public void setTerrain(boolean newState) {
        if (!hasTerrain() && newState) {
            float scaleFactor = scene.getScaleFactor();
            float baseY = -2.5f / scaleFactor;
            float topY = 50f / scaleFactor;
            addTerrain("basin", baseY, topY);

        } else if (hasTerrain() && !newState) {
            terrain.delete();
            terrain = null;
        }
    }
    // *************************************************************************
    // NodeControl methods

    /**
     * Remove all spatials from this map.
     */
    @Override
    public void deleteAll() {
        super.deleteAll();
        ground = null;
        terrain = null;
    }
    // *************************************************************************
    // private methods

    /**
     * Add terrain to the map. A map may contain at most one terrain.
     *
     * @param topography name of the topography asset (not null)
     * @param baseY lowest possible y-coordinate
     * @param topY highest actual y-coordinate
     */
    private void addTerrain(String topography, float baseY, float topY) {
        if (terrain != null) {
            logger.log(Level.WARNING, "Extra terrain ignored.");
            return;
        }
        terrain = new TerrainControl(scene, topography, baseY, topY);
    }

    /**
     * Generate the file system path for the user's saved map.
     *
     * @return a path string (not null)
     */
    private String getSavePath() {
        String path = Misc.getUserPath(saveFileName);
        assert path != null;
        return path;
    }

    /**
     * Process a "load map" action. Assumes that the scene is in world-building
     * mode.
     *
     * @param mapName name of map to load, either "test" or "user" or an asset
     * under "Scenes/maps" (not null)
     */
    private void load(String mapName) {
        assert mapName != null;
        assert scene.isBuildMode();

        switch (mapName) {
            case "test":
                String assetPath =
                        String.format("Scenes/maps/%s", saveFileName);
                loadAsset(assetPath);
                break;
            case "user":
                loadUserFile();
                break;
            default:
                assetPath = String.format("Scenes/maps/%s.xml", mapName);
                loadAsset(assetPath);
        }
    }

    /**
     * Load a map from an asset. Assumes that the scene is in world-building
     * mode.
     *
     * @param assetPath asset path to load from (not null)
     */
    private void loadAsset(String assetPath) {
        assert assetPath != null;
        assert scene.isBuildMode();
        logger.log(Level.INFO, "Loading a map from an asset at {0}",
                MyString.quote(assetPath));

        Document document = Assets.loadXml(assetPath);
        loadXmlDocument(document);
    }

    /**
     * Load this map from the user's home folder. Assumes that the scene is in
     * world-building mode.
     */
    private void loadUserFile() {
        assert scene.isBuildMode();

        String path = getSavePath();
        loadXmlFile(path);
    }

    /**
     * Load this map from an XML document. Assumes that the scene is in
     * world-building mode.
     *
     * @param document from which to load XML map (not null)
     */
    private void loadXmlDocument(Document document) {
        assert scene.isBuildMode();
        /*
         * Convert the DOM document into a new map.
         */
        String encoding = document.getXmlEncoding();
        if (!encoding.equals(xmlEncoding)) {
            logger.log(Level.SEVERE,
                    "Unexpected encoding {0} while loading an XML map",
                    MyString.quote(encoding));
            return;
        }
        String version = document.getXmlVersion();
        if (!version.equals(xmlVersion)) {
            logger.log(Level.SEVERE,
                    "Unexpected version {0} while loading an XML map",
                    MyString.quote(version));
            return;
        }
        readDocument(document);
    }

    /**
     * Load this map from an XML stream. Assumes that the scene is in
     * world-building mode.
     *
     * @param stream stream to load XML map from (not null)
     */
    private void loadXmlStream(InputStream stream) {
        assert stream != null;
        assert scene.isBuildMode();
        /*
         * Parse the stream into a DOM document.
         */
        Document document = XmlLoader.parse(stream, "a map asset");
        loadXmlDocument(document);
    }

    /**
     * Load this map from an XML file. Assumes that the scene is in
     * world-building mode.
     *
     * @param path filesystem path to load map from (not null)
     */
    private void loadXmlFile(String path) {
        assert path != null;
        assert scene.isBuildMode();
        logger.log(Level.INFO, "Loading a map from an XML file at {0}",
                MyString.quote(path));

        FileInputStream stream;
        try {
            stream = new FileInputStream(path);
        } catch (FileNotFoundException exception) {
            logger.log(Level.SEVERE, "Didn''t find an XML map file at {0}.",
                    MyString.quote(path));
            return;
        }
        loadXmlStream(stream);
    }

    /**
     * Reconstruct a saved map from a DOM document.
     *
     * @param document which document to read (not null)
     */
    private void readDocument(Document document) {
        Element element = document.getDocumentElement();
        element.normalize();

        String tag = element.getTagName();
        if (!tag.equals(xmlTag)) {
            logger.log(Level.SEVERE, "Found unexpected tag {0} in XML map.",
                    MyString.quote(tag));
            return;
        }
        /*
         * Start with an empty map.
         */
        deleteAll();
        PickablesNode pickables = scene.getWorld().getPickables();
        pickables.deleteAll();

        NamedNodeMap attributes = element.getAttributes();
        float newRadius = DomInput.getFloat(attributes, "radius", 50f);
        if (newRadius > 0f) {
            radius = newRadius;
        } else {
            logger.log(Level.SEVERE,
                    "Ignored invalid radius {0} in XML element {1}.",
                    new Object[]{
                newRadius, MyString.quote(xmlTag)
            });
        }

        NodeList list = document.getElementsByTagName(Blocks.xmlTag);
        for (int i = 0; i < list.getLength(); i++) {
            Element blockElement = (Element) list.item(i);
            blocks.add(blockElement);
        }

        list = document.getElementsByTagName(GroundControl.xmlTag);
        for (int i = 0; i < list.getLength(); i++) {
            Element groundElement = (Element) list.item(i);
            NamedNodeMap map = groundElement.getAttributes();
            float topY = DomInput.getFloat(map, "topY", 0f);
            topY /= scene.getScaleFactor(); // convert meters to world units

            addGround(topY);
        }

        list = document.getElementsByTagName(TerrainControl.xmlTag);
        for (int i = 0; i < list.getLength(); i++) {
            Element terrainElement = (Element) list.item(i);
            NamedNodeMap map = terrainElement.getAttributes();
            float baseY = DomInput.getFloat(map, "baseY", 0f);
            baseY /= scene.getScaleFactor(); // convert meters to world units

            float topY = DomInput.getFloat(map, "topY", 100f);
            topY /= scene.getScaleFactor(); // convert meters to world units

            Attr attr = (Attr) map.getNamedItem("topography");
            if (attr == null) {
                logger.log(Level.SEVERE,
                        "Missing terrain attribute \"topography\" in XML map.");
            } else {
                String topography = attr.getNodeValue();
                addTerrain(topography, baseY, topY);
            }
        }
    }

    /**
     * Save this map to the user's home folder. Assumes that the scene is in
     * world-building mode.
     */
    private void saveUserFile() {
        assert scene.isBuildMode();
        String path = getSavePath();
        saveXmlFile(path);
    }

    /**
     * Save this map to an XML file. Assumes that the scene is in world-building
     * mode.
     *
     * @param path file path to save to (not null)
     */
    private void saveXmlFile(String path) {
        assert path != null;
        assert scene.isBuildMode();
        logger.log(Level.INFO, "Saving the map to an XML file at {0}",
                MyString.quote(path));

        FileOutputStream stream;
        try {
            stream = new FileOutputStream(path);
        } catch (FileNotFoundException exception) {
            logger.log(Level.WARNING, "File Not Found exception while saving"
                    + " the map to an XML file at {0}!",
                    MyString.quote(path));
            return;
        }
        boolean autoFlush = false;
        PrintStream printStream;
        try {
            printStream = new PrintStream(stream, autoFlush, xmlEncoding);
        } catch (UnsupportedEncodingException exception) {
            logger.log(Level.SEVERE, "Unsupported encoding exception while "
                    + "saving the map to an XML file at {0}!",
                    MyString.quote(path));
            return;
        }

        printStream.printf("<?xml version=\"%s\" encoding=\"%s\" ?>%n",
                xmlVersion, xmlEncoding);
        writeXml(printStream);
        try {
            stream.close();
        } catch (IOException exception) {
            logger.log(Level.WARNING, "Output exception while saving"
                    + " the map to an XML file at {0}!",
                    MyString.quote(path));
        }
    }

    /**
     * Lock or unlock all doors of all gateway blocks. Assumes that the scene is
     * in simulation mode.
     *
     * @param newState if true, lock all doors; if false, unlock all doors
     */
    private void setLockedAllGateways(boolean newState) {
        assert scene.isSimMode();

        for (Spatial child : getChildren()) {
            String prefix = SpatialProperties.getPrefixType(child);
            if ("gateway".equals(prefix)) {
                Node childNode = (Node) child;
                blocks.setLocked(childNode, newState);
            }
        }
    }

    /**
     * Toggle this map's ground plane on or off.
     */
    private void toggleGround() {
        boolean newState = !hasGround();
        setGround(newState);
    }

    /**
     * Toggle this map's terrain on or off.
     */
    private void toggleTerrain() {
        boolean newState = !hasTerrain();
        setTerrain(newState);
    }

    /**
     * Serialize the map to an XML stream. Assumes that the scene is in
     * world-building mode.
     *
     * @param stream output stream (not null)
     */
    private void writeXml(final PrintStream stream) {
        assert scene.isBuildMode();

        stream.printf("<%s", xmlTag);
        XmlOutput.putAttribute(stream, "radius", radius, 4);
        stream.println(">");
        /*
         * Serialize each block.
         */
        PickablesNode pickables = scene.getWorld().getPickables();
        final String indent = "    ";
        SceneGraphVisitor visitor = new SceneGraphVisitor() {
            @Override
            public void visit(Spatial spatial) {
                if (SpatialProperties.isBlock(spatial)) {
                    blocks.writeXml(stream, spatial, indent);
                }
            }
        };
        pickables.visitAll(visitor);

        if (ground != null) {
            /*
             * Serialize the ground.
             */
            ground.writeXml(stream, indent);
        }

        if (terrain != null) {
            /*
             * Serialize the terrain.
             */
            terrain.writeXml(stream, indent);
        }
        stream.printf("</%s>", xmlTag);
    }
}